Skip to content

Merge type extensions into the types they extend - #855

Open
oryan-block wants to merge 14 commits into
masterfrom
bugfix/552
Open

oryan-block wants to merge 14 commits into
masterfrom
bugfix/552

Conversation

@oryan-block

@oryan-block oryan-block commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #552

Checklist

  • Pull requests follows the contribution guide
  • New or modified functionality is covered by tests

Description

Extending a union, enum, interface or scalar replaces the type instead of adding to it, and no error is raised. On master, union FooBar = Foo | Bar plus extend union FooBar = Baz gives members [Baz], enum Color { RED } plus extend enum Color { GREEN } gives values [GREEN], and interface Node { id: ID } plus extend interface Node { name: String } gives fields [name]. With the usual base-then-extension order it's the base that gets lost, which is what Sceri hit: interface fields go missing and things break at runtime. Object and input extensions merge their fields, but their directives are dropped. So a federation entity declared with type Product @key(fields: "upc") plus extend type Product @key(fields: "name") keeps only the first @key, which is the gap left by #345. extend type X implements Y is dropped too.

UnionTypeExtensionDefinition, EnumTypeExtensionDefinition, InterfaceTypeExtensionDefinition and ScalarTypeExtensionDefinition are subclasses of their base definitions. SchemaClassScanner only filtered out object and input extensions, so the others stayed in definitionsByName and replaced the base definition (associateBy, last one wins). SchemaParser#createObject and SchemaParser#createInputObject only read directives from the base definition, and createObject only read its implements.

The scanner now keeps only base definitions in definitionsByName and groups every extension by type name in a new typeExtensions map, which goes to the parser through ScannedSchemaObjects. When discovering types it also reads union-extension members, interface-extension fields and extend type X implements Y. Every create* function in SchemaParser now merges the extensions' members, values, fields and interfaces, sets extensionDefinitions, and applies the directives of the base followed by those of the extensions (SchemaParser#directivesOf), which is what graphql-java's SchemaGenerator does. Scalars get their SDL description, their base definition and extensionDefinitions. Object and input field handling is unchanged. There's a small extensionsOf<T>(name) helper in Utils.kt.

graphql-java rejects an extension that repeats an enum value, an interface field, a union member or a non-repeatable directive. I didn't copy that. Those schemas built on master, since the extension replaced the type, so tools keeps the first enum value, interface field or union member and logs a WARN. #863 will make them fail in 15.0.0. A declared non-repeatable directive (or a built-in one) that an extension applies again is kept once, the first one, with a WARN. Without that it'd be applied twice in the applied directives and once in the legacy ones, and SchemaPrinter would print invalid SDL. Undeclared directives (with allowUndeclaredDirectives) are all kept, since they might be repeatable, like an undeclared federation @key.

A union, enum, interface or scalar extension with no base type isn't valid per the spec, but it built as the type on master, so I kept that. Several such extensions of one name are now merged instead of the last one winning. Object and input extensions without a base type are still ignored. An extension whose kind doesn't match its type (e.g. type Foo plus extend union Foo) is ignored with a WARN that names the type and both kinds, and the type is built as it's defined.

Left out, and worth separate issues:

  • Scalar directives. Scalars are still built in the scanner without any directives, base or extension, including @specifiedBy. Fixing that means moving scalar creation to the parser.
  • Object fields still keep the extension's definition when a name is repeated, and input objects still list extension fields before base fields. Changing either would change existing behaviour or the order of printed SDL.
  • A nested union that's only reachable as a member of another union still fails in the scanner with "No object type found". That's not new.

#842, #843, #852 and #838 are merged in. #842's findUsage now walks typeExtensions too, so its error hints name the fields of extend interface like those of the interface itself. The scanner's separate extensionDefinitions and inputExtensionDefinitions lists are gone. Object and input extensions come from typeExtensions like the others and no longer go to the parser among the definitions. #852's check for enums that only directives use now also looks at the fields of interface extensions, and those enums get the values of their extend enum like any other enum. #854's lookup of sealed subclasses also reads the members of extend union and extend type X implements Y.

The new TypeExtensionTest has 12 tests and runs queries where it matters. One schema covers merged union members, enum values (with the extension declared first), interface fields and implements, extend type X implements Y and scalar descriptions, plus definition and extensionDefinitions on each. Two more cover a nested union added by an extension and a union used only by an interface-extension field. The scanner wants classes for the members of a union and of its extensions, in both declaration orders, and for objects that implement an interface only through an extension. The parser wants enum constants for the values of the enum and of its extensions, and implementors need the interface's own fields. For directives, one schema applies a repeatable directive to an object, interface, union, enum and input object and to their extensions. It also has a non-repeatable directive that only an extension applies and one that an extension applies again. It checks the applied directives, that SchemaDirectiveWiring fires once for each, the legacy directives and the SchemaPrinter output. Undeclared extension directives are checked with and without allowUndeclaredDirectives. It also covers includeUnusedTypes, getUnusedDefinitions(), repeated values, fields and members, extension-only enums and unions, ignored extensions of a different kind, the error hint for an interface-extension field, and an extend enum of an enum that only a directive uses. On master 8 tests fail and 2 error. The error hint and kind mismatch tests pass there too, since master builds the interface from its extension and also ignores the mismatched extensions in that schema. clean verify passes with 300 tests on graphql-java 26.1, and so does -Dgraphql-java.version=25.1.

Behaviour change: union, enum, interface and scalar extensions now add to their type instead of replacing it. Directives on extend type, extend input, extend interface, extend union and extend enum are now applied and passed to SchemaDirectiveWiring, and extend type X implements Y takes effect. Some schemas that built before now fail. Every member of a union and its extensions needs a class, every enum value of the type and its extensions must exist in the Java enum, implementors must declare the interface's own fields, and unions and interfaces used only by fields that used to be dropped are now discovered, so their members and implementors need classes too. An undeclared directive on an extension fails with "Found applied directive ... without corresponding directive definition" unless allowUndeclaredDirectives is set. An extension of a different kind than its type is ignored with a WARN, where on master a union, enum, interface or scalar extension like that could replace the type. In type errors, a type that only has an extend type or extend input is now "not defined in the schema" instead of "can't be used as an input type" (or output type). Where an extension repeats an enum value, interface field or union member, the first one declared is kept with a WARN, where master kept the last. definition is the type's own definition, or the first extension for a union, enum, interface or scalar that only has extensions, and that's also what getUnusedDefinitions() reports. getExtensionDefinitions() is filled on every kind, so SchemaPrinter with useAstDefinitions(true) prints the extensions. Probably worth a minor version.

🤖 Generated with Claude Code

oryan-block and others added 12 commits October 12, 2026 15:48
Union, enum, interface and scalar extensions subclass their base
definitions, so they replaced the base type (whichever came last won)
instead of being merged into it. Directives and implemented interfaces
on object and input extensions were dropped.

The scanner now keeps the base definitions and groups every extension
by type name. It reads union members, interface fields and implemented
interfaces from the extensions too. The parser merges members, values,
fields, interfaces and directives like graphql-java's SchemaGenerator,
and sets extensionDefinitions on every type.

graphql-java rejects an extension that repeats a value, field or
non-repeatable directive of its type. Here enum values, interface
fields and directives keep the first one, so schemas that repeated them
to work around this keep building. Object and input fields are handled
as before.

An extension of a different kind than its type is now an error. A
union, enum, interface or scalar extension without a definition still
builds as the type itself, as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The scanner no longer passes them to the parser among the definitions. The enums used by directives are now also checked against the fields of interface extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also checks that enums used only by directives get the values of their extensions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An undefined type that only has an object or input extension is now reported as not defined. The directives test also covers a non-repeatable directive that only an extension applies, and checks that each directive is wired once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…or enum

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	src/main/kotlin/graphql/kickstart/tools/SchemaClassScanner.kt
…tensions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Type extensions aren't merged: union/enum/interface/scalar extensions replace the base type, directives on object/input extensions are dropped

1 participant